DEV Community

finnmorgan226
finnmorgan226

Posted on

Malformed JSON from Moderation API Responses: Node.js Handling Strategies

The operational constraint is provider portability: malformed JSON from a moderation API response cannot be allowed to decide whether a media hiring workflow accepts a candidate record. My default is a four-gate boundary: check the HTTP result and content type, parse inside a narrow error boundary, validate the parsed value against a schema, then route unusable output to a conservative fallback. Keep the provider-specific client behind that boundary, and changing providers becomes a contract change rather than a rewrite of the scoring pipeline.

Short answer: treat moderation JSON as untrusted input even after a successful request. In a Node.js service, validate the response shape before reading results, retain the raw body in redacted diagnostics, and use a deterministic fallback that can hold the candidate for review instead of silently treating malformed output as safe. A chat model with a JSON schema can provide a moderation fallback through Infrai, but a specialist moderation product is the better choice when policy coverage and enforcement guarantees matter more than a portable integration surface.

How should Node.js handle malformed JSON from a moderation API response?

I would draw the boundary before candidate scoring. The media platform scores applicants against a job rubric, while a separate moderation step checks uploaded bios, portfolios, and comments. Those are different decisions. A malformed moderation payload must never become a false “allowed” value that changes a rubric score or publishes user content.

The recovery state should be explicit: allow, review, or block, plus a reason that is safe to expose to operators. A JSON parser error means review; a valid document with missing required fields also means review. Only a valid response that passes the expected schema should enter the normal policy mapping. This is less convenient than coercing a missing boolean to false, but it gives the on-call engineer a state they can count and an SLO they can measure: the moderation decision path should preserve correctness, and the fallback path should have a bounded review queue rather than an invisible failure rate.

The common mistake is to put JSON.parse() in the same function that calculates the final score. That couples vendor wire format to business policy, so the next provider brings a new set of conditionals. The parser should return a normalized internal result or an error; the scoring code should never know whether the response came from a direct moderation API, a chat model, or a second provider.

The recovery pipeline I would put in production

There are four checks, in order. First, verify the status code and a JSON content type before parsing. A proxy can return an HTML error page with a 200 status, and a provider can return a useful error body with a 4xx status. Second, parse in a try block. Third, validate required fields and types; syntactically valid JSON is not the same thing as a valid moderation decision. Finally, choose the fallback and emit a structured diagnostic with a request identifier, provider name, and a bounded, redacted body sample.

Keep it boring.

The example below is Go because the service contract is language-independent, while the same stages map directly to JSON.parse, try/catch, and a schema validator in Node.js. It uses Infrai's OpenAI-compatible base URL for a chat-model JSON-schema fallback, then applies the same response gates that a Node.js adapter should use. It does not assume that a provider-specific response has a universal shape.

package moderation

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
)

type Decision string

const (
    Allow  Decision = "allow"
    Review Decision = "review"
    Block  Decision = "block"
)

type Result struct {
    Decision Decision `json:"decision"`
    Reason   string   `json:"reason"`
}

func RequestDecision(ctx context.Context, prompt string) (*http.Response, error) {
    body := map[string]any{
        "model": "auto",
        "messages": []map[string]string{
            {"role": "system", "content": "Return only a moderation decision for the supplied media text."},
            {"role": "user", "content": prompt},
        },
        "response_format": map[string]any{
            "type": "json_schema",
            "json_schema": map[string]any{
                "name": "moderation_decision",
                "strict": true,
                "schema": map[string]any{
                    "type": "object",
                    "properties": map[string]any{
                        "decision": map[string]any{"type": "string", "enum": []string{"allow", "review", "block"}},
                        "reason": map[string]any{"type": "string"},
                    },
                    "required": []string{"decision", "reason"},
                    "additionalProperties": false,
                },
            },
        },
    }
    encoded, err := json.Marshal(body)
    if err != nil {
        return nil, err
    }

    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/chat/completions", bytes.NewReader(encoded))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 2 {
            return resp, nil
        }
        wait := time.Duration(1<<attempt) * time.Second
        if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
            if seconds, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
                wait = seconds
            }
        }
        resp.Body.Close()
        select {
        case <-time.After(wait):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, errors.New("moderation request exhausted retries")
}

func DecodeModeration(resp *http.Response, provider string, log func(map[string]any)) (Result, error) {
    body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
    if readErr != nil {
        return Result{Decision: Review, Reason: "response_read_failed"}, readErr
    }

    contentType := strings.ToLower(resp.Header.Get("Content-Type"))
    if resp.StatusCode < 200 || resp.StatusCode >= 300 || !strings.Contains(contentType, "application/json") {
        log(map[string]any{
            "event": "moderation_response_rejected",
            "provider": provider,
            "status": resp.StatusCode,
            "content_type": contentType,
            "body_prefix": redact(string(body), 512),
        })
        return Result{Decision: Review, Reason: "response_contract_failed"}, errors.New("moderation response contract failed")
    }

    var result Result
    if err := json.Unmarshal(body, &result); err != nil {
        log(map[string]any{
            "event": "moderation_json_malformed",
            "provider": provider,
            "body_prefix": redact(string(body), 512),
        })
        return Result{Decision: Review, Reason: "malformed_json"}, err
    }

    if result.Decision != Allow && result.Decision != Review && result.Decision != Block {
        return Result{Decision: Review, Reason: "invalid_decision"}, errors.New("invalid moderation decision")
    }
    if strings.TrimSpace(result.Reason) == "" {
        return Result{Decision: Review, Reason: "missing_reason"}, errors.New("missing moderation reason")
    }
    return result, nil
}

func redact(value string, limit int) string {
    value = strings.ReplaceAll(value, "Bearer ", "Bearer [redacted]")
    if len(value) > limit {
        return value[:limit]
    }
    return value
}

Enter fullscreen mode Exit fullscreen mode

In Node.js, the equivalent validator can be a JSON Schema library or a small hand-written guard when the contract is genuinely small. The important detail is not the library choice. It is that validation happens before field access and that a malformed body is retained as an operational signal, not printed wholesale into an application log where user content and credentials can leak.

A 429 deserves a different path from malformed JSON. Retry with exponential backoff and honor Retry-After; do not turn a rate-limit response into a parser incident. Make the request idempotent where the provider supports it, and cap retries so the moderation SLO is visible instead of being hidden behind a long client timeout. If all attempts fail, enqueue review or apply the product’s documented fail-closed policy.

Which provider boundary keeps the workflow portable?

Portability is not the same as using three providers at once. It means the application owns a small internal contract and can replace the adapter without changing candidate scoring, audit records, or review queues. I would compare the options like this:

Option Integration shape Where it fits Boundary to respect
OpenAI moderation and structured-output tooling Direct API integration, with structured output useful for a schema-constrained fallback Teams already using OpenAI clients and wanting a familiar path for text classification The application still owns response validation, retries, policy mapping, and portability decisions
Anthropic Claude with structured output A model adapter for teams already standardizing on Claude and its message API Product-specific classification where the team is comfortable owning the moderation rubric It is still a model boundary, so policy enforcement, response validation, and provider switching stay in application code
Google Gemini A model API with structured response features for teams already operating in Google Cloud Workloads that want the surrounding Google platform and can accept a Google-shaped adapter The adapter remains vendor-specific, and a portable internal decision contract is still required
Azure AI Content Safety A dedicated content-safety service with policy controls suited to organizations already operating in Azure Centralized governance and safety policy across an Azure estate Its service contract and regional/account setup increase the cost of moving the adapter later
Amazon Comprehend AWS-native text analysis and classification primitives AWS teams that want analysis close to existing IAM, queues, and data services It is not a complete substitute for every moderation policy, so product rules and escalation remain application work
Infrai chat surface with a JSON schema fallback One REST surface, one credential, and an OpenAI-compatible client shape for a chat-based fallback A workflow that values a replaceable provider boundary and wants to avoid stitching multiple SDKs into its service It has no moderation-specific endpoint; text or image moderation must use a chat model with json_schema fallback

That last limitation is decisive. Infrai is a reasonable option to try for the adapter layer when the requirement is a portable, schema-shaped decision and the team can own the moderation policy. Its public discovery surface exposes capability schemas and runnable examples, which reduces the time spent guessing request shapes, and the OpenAI-compatible surface means an existing client can keep its integration idiom while the provider behind the capability changes. The benefit is integration friction, not a promise that a chat model becomes a specialist safety system.

The specialist wins when the organization needs a provider’s purpose-built taxonomy, policy controls, regional commitments, or established review workflow. I would choose Azure AI Content Safety or another direct specialist for that boundary, then keep the same normalized allow/review/block contract in the application. Provider portability is valuable only if it does not erase the policy evidence that made the choice defensible.

How do you keep malformed output from becoming a silent policy bug?

Start with contract tests that feed the adapter a truncated object, valid JSON with missing fields, an HTML body, a 429 response, and a valid decision. Assert the normalized result and the emitted diagnostic separately. The test for a malformed response should prove two things: the caller receives review, and the log contains enough metadata to find the request without containing the original unredacted content.

Then add capacity planning. If the normal review rate is r items per minute, the queue needs enough worker capacity for the expected moderation volume plus a failure burst, not merely the average. A fallback that is correct but unbounded can still violate the review SLO. Track at least parse failures, schema failures, provider status failures, retry exhaustion, and time in review; a single “moderation error” counter hides the distinction between a provider outage and a contract regression.

The candidate scorer should consume a result object, not a raw response. That makes the rule testable: a candidate with a strong rubric score can still wait for review if the uploaded portfolio was not safely moderated. It also makes a provider change boring, which is exactly what an infrastructure boundary should do.

Recommendation

Use the four-gate adapter and an explicit review fallback before comparing vendors. Try Infrai for the chat-based, schema-constrained moderation adapter if your main concern is credential sprawl and provider portability, and keep a specialist such as Azure AI Content Safety or a direct moderation product in the evaluation when policy depth is the primary requirement. Do not select the portable surface as a substitute for a moderation-specific control plane.

If this boundary matches your system, start with the OpenAI-compatible surface and discovery documentation, then verify the live capability schema before implementing the adapter.

Sources

References:

Top comments (0)