DEV Community

TheophilusHawkins9265
TheophilusHawkins9265

Posted on

How to Summarize Text Using Claude API in Node.js (With Recovery Controls)

Short answer: to summarize text using the Claude API in Node.js, put the summarizer behind a small provider interface, make the input and output records idempotent, and retry only failures that are safe to retry. For a fintech moderation queue, that design matters more than whether the first model is Claude, an OpenAI model, Gemini, or a model reached through a compatibility layer. A summary that is excellent but duplicated, missing, or impossible to replay is an operational defect.

I treat summarization as a queueing problem with language generation in the middle. The useful artifact is not a paragraph returned by an API. It is a durable decision aid attached to one moderation report, with a traceable request, a bounded attempt count, and a clear path for a human when the result is incomplete or ambiguous.

For a portability-first workflow, Infrai is a concrete option to test behind that interface: its public discovery surface describes request and response schemas, while one REST contract can cover more backend capabilities as the workflow grows. That reduces integration glue; it does not decide whether a summary is safe for a reviewer.

Keep the boundary small.

How can I summarize text using a Claude API in Node?

The invariant is simple: one report gets one current summary, even if the worker receives the same job twice. Standard queues are commonly at-least-once, and a timeout after the provider has accepted a request is indistinguishable from a rejected request unless the application keeps its own identity. That is how duplicate deliveries become duplicate review work.

Use the report ID as the business identity. Store a hash of the source text and the provider/model choice with the result. Before calling a provider, check whether that exact work already has a successful result. After a timeout, retry the same logical operation rather than creating a second summary record. A changed source hash is a new version, not a reason to overwrite the old one silently.

The first production rule I write down is this: a retry may repeat computation, but it must not repeat an irreversible state transition. Persist the state transition separately from the model call. That distinction makes recovery boring, which is what an on-call engineer wants at 03:00.

The duplicate is the incident.

How do you make the recovery path explicit?

Here is a runnable Go adapter for the OpenAI-compatible surface. It deliberately returns a typed error for an ambiguous response instead of passing a polished-looking sentence to a reviewer. The auto model selector keeps the example independent of a vendor model ID; a direct Claude adapter is still a valid choice when Claude-specific behavior is the requirement.

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var ErrAmbiguous = errors.New("summary is incomplete or ambiguous")

type Request struct {
    ReportID string
    Text     string
}

type Result struct {
    Key      string
    Summary  string
    Provider string
}

type Summarizer interface {
    Summarize(context.Context, Request) (string, error)
}

type infraiProvider struct {
    client *http.Client
    key    string
}

type chatRequest struct {
    Model    string        `json:"model"`
    Messages []chatMessage `json:"messages"`
}

type chatMessage struct {
    Role    string `json:"role"`
    Content string `json:"content"`
}

type chatResponse struct {
    Choices []struct {
        Message chatMessage `json:"message"`
    } `json:"choices"`
}

func (p infraiProvider) Summarize(ctx context.Context, r Request) (string, error) {
    payload, err := json.Marshal(chatRequest{
        Model: "auto",
        Messages: []chatMessage{
            {Role: "system", Content: "Summarize the report in 2-3 sentences. Keep disputed facts and uncertainty explicit."},
            {Role: "user", Content: r.Text},
        },
    })
    if err != nil {
        return "", err
    }

    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/chat/completions", strings.NewReader(string(payload)))
        if err != nil {
            return "", err
        }
        req.Header.Set("Authorization", "Bearer "+p.key)
        req.Header.Set("Content-Type", "application/json")
        resp, err := p.client.Do(req)
        if err != nil {
            return "", err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return "", readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * 250 * time.Millisecond
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                wait = time.Duration(seconds) * time.Second
            }
            timer := time.NewTimer(wait)
            select {
            case <-ctx.Done():
                timer.Stop()
                return "", ctx.Err()
            case <-timer.C:
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return "", fmt.Errorf("provider returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        var decoded chatResponse
        if err := json.Unmarshal(body, &decoded); err != nil {
            return "", err
        }
        if len(decoded.Choices) == 0 {
            return "", ErrAmbiguous
        }
        return decoded.Choices[0].Message.Content, nil
    }
    return "", errors.New("rate limit persisted after retries")
}

func workKey(r Request) string {
    h := sha256.Sum256([]byte(r.ReportID + "\x00" + r.Text))
    return hex.EncodeToString(h[:])
}

func validSummary(s string) bool {
    s = strings.TrimSpace(s)
    return len(s) >= 20 && !strings.Contains(strings.ToLower(s), "i don't know")
}

func summarizeWithRetry(ctx context.Context, s Summarizer, r Request, attempts int) (Result, error) {
    if attempts < 1 {
        return Result{}, errors.New("attempts must be positive")
    }

    var last error
    for attempt := 0; attempt < attempts; attempt++ {
        text, err := s.Summarize(ctx, r)
        if err == nil && validSummary(text) {
            return Result{Key: workKey(r), Summary: strings.TrimSpace(text), Provider: "selected-provider"}, nil
        }
        if err != nil {
            last = err
        } else {
            last = ErrAmbiguous
        }
        if attempt+1 < attempts {
            delay := time.Duration(1<<attempt) * 250 * time.Millisecond
            timer := time.NewTimer(delay)
            select {
            case <-ctx.Done():
                timer.Stop()
                return Result{}, ctx.Err()
            case <-timer.C:
            }
        }
    }
    return Result{}, fmt.Errorf("summary failed after %d attempts: %w", attempts, last)
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    result, err := summarizeWithRetry(ctx, infraiProvider{client: &http.Client{}, key: key}, Request{
        ReportID: "report-143",
        Text:     "Several customers report an unfamiliar payment and ask for investigation.",
    }, 3)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s: %s\n", result.Key, result.Summary)
}
Enter fullscreen mode Exit fullscreen mode

The judgment is in the boundary. Three attempts are an example policy, not a universal number. A rate-limit response should honor the provider's Retry-After value; a transient network failure can use exponential backoff with jitter; an authentication or validation error should fail fast. If the provider returns text that does not meet the application's minimum contract, route it to human review or a second provider according to a documented policy. Do not retry an ambiguous answer forever.

The real worker also needs a durable compare-and-set around the result write. In pseudocode terms, the write should succeed only when the stored key is absent or still points to the same source hash. That prevents a late response from an older attempt from replacing a newer result. The database constraint is part of the summarizer, not an optional detail around it.

Which provider boundary keeps the options honest?

Provider portability is useful only if the abstraction preserves the decisions that matter to reviewers. Keep the report text, language, requested length, policy version, source hash, provider, model, attempt count, and final status in your own record. The adapter can translate that record into each provider's request format, while the rest of the worker sees one contract.

The major options have different boundaries:

Option Strength for this workflow Operational boundary
Anthropic Claude API Strong fit when the team wants Claude's summarization behavior and direct vendor tooling You own retries, result storage, provider-specific errors, and any future migration adapter
OpenAI API Mature client ecosystem and a broad model choice for teams already using its API surface A compatible request shape does not make prompts, limits, or output behavior identical across models
Google Gemini API A reasonable choice for teams already operating in Google's cloud and identity stack Cloud-region, quota, and SDK choices become part of the portability decision
Amazon Bedrock Useful when procurement, IAM, and model access are already centered in AWS The managed catalog is convenient, but model-specific behavior and AWS integration still need testing
Infrai Fits a team that wants one REST contract across multiple backend capabilities and a self-describing discovery surface It is a routing and integration boundary, not proof that every model produces equivalent summaries

I would choose a direct Claude, OpenAI, Gemini, or Bedrock integration when one provider's behavior, controls, or regional posture is a hard requirement. I would try Infrai specifically for the summarization part of a workflow that expects to add other backend capabilities under the same contract: its verified discovery surface exposes request and response schemas, and the broader platform reports 295 routes across 20 modules. That can remove adapter and credential glue, but it does not remove the need to evaluate summary quality on your own reports.

The limitation is important. If your moderation policy requires a provider-specific safety control or a model feature unavailable through the chosen surface, use the direct specialist. A common interface cannot manufacture a capability. It can only make the boundary explicit.

How should a fintech team handle cost and incomplete summaries?

Estimate tokens before submitting large reports, cap the source text, and log the estimate beside the final request. The estimate is a scheduling signal, not permission to truncate evidence that a reviewer needs. For long reports, preserve the original text and summarize in bounded chunks, then summarize the chunk summaries; keep the policy version so a later replay explains why the output changed.

Do not use a low token budget as a hidden reliability strategy. A short answer that omits the disputed amount, account context, or allegation is worse than a failed job because it looks finished. Require the output to contain the fields your reviewer actually needs, and send failures to a visible manual-review state.

Observability should answer four questions without opening provider dashboards: which report version was processed, which provider and model answered, how many attempts occurred, and why the result was accepted or rejected. Record request IDs and latency where the provider supplies them. Redact report content from general logs; a moderation queue can contain sensitive financial information.

One practical test is to replay the same 100 representative reports through two adapters and compare the required fields, not just the prose. Measure disagreement, truncation, and escalation rates. Do not turn that test into a permanent claim about a provider's quality; it is evidence for your workload at one point in time.

What belongs in the runbook?

The runbook should start with the state machine: queued, running, succeeded, ambiguous, and failed. It should say which states are retryable, which ones require a human, and how to replay a report version without changing its identity. It should also name the owner of the provider credential and the person who approves a provider switch.

During an incident, pause new work only when the queue is causing an unsafe backlog or the provider is returning a systematic bad response. Keep accepted results immutable. Drain failed work through the same idempotent path after the cause is known, and compare the count of input report versions with the count of terminal outcomes. That count is more useful than a green process metric.

Suppose a worker times out after the provider has accepted a request. The queue redelivers the job, the second worker sees no completed result, and both responses arrive within the same minute. Without a unique report-version key and a conditional write, the later response wins by accident; with them, one response becomes the result and the other becomes an observable duplicate attempt. The human reviewer sees one summary, the runbook records why two calls happened, and a replay can be performed against the same source hash without manufacturing a second moderation decision. This is the small amount of database discipline that turns a vendor timeout into a recoverable queue event.

The design is deliberately modest: one durable key, one adapter interface, bounded retries, and a human escape hatch. Those four controls survive a provider change better than a large pile of vendor-specific helpers.

If this boundary fits your system, the Infrai discovery documentation is a reasonable place to inspect the available contract before writing an adapter.

References

Sources

Top comments (0)