Short answer: To debug a malformed JSON response from an email send API, preserve the status, headers, content type, and bounded body before parsing, then classify the result as transport, protocol, contract, or delivery evidence. For a password-reset message with a 15-minute expiry, that ordering matters because a retry can create two usable-looking links or hide the original failure.
A malformed JSON response from an email send API is usually a contract failure at a boundary, not an email-deliverability failure. Alert on the rate of ambiguous outcomes rather than on parser exceptions alone.
How should I debug malformed JSON in an email send response?
The page says password_reset_send_parse_error: 3.2% over five minutes. The client log says unexpected end of JSON input. A user says the reset email never arrived. Those are three signals, not one diagnosis.
Start with the response envelope. Capture the request correlation ID, HTTP status, content type, content length when present, and a redacted body sample. Do not log the reset token, the recipient address, or an unbounded error page. A gateway can return HTML with status 502; a service can return an empty body with status 202; a proxy can truncate a perfectly valid JSON document. The parser should not be asked to decide which one happened.
The first useful split is mechanical:
- No response: connection, timeout, DNS, or TLS problem.
- A response with a non-JSON media type: protocol or intermediary problem.
- JSON that fails to parse: malformed or incomplete representation.
- Valid JSON with an unknown shape: contract drift or a deployment mismatch.
- Valid success shape: continue to delivery and provider-status checks.
That classification gives the on-call engineer a next action. It also prevents the common mistake of treating every parse error as a safe retry.
Unknown is a state.
This method has a boundary: the response can establish what the send endpoint returned, but it cannot prove mailbox delivery. A richer status API may reduce uncertainty, while a durable local record may be the better trade-off when that API is unavailable. Do not treat either choice as a replacement for downstream delivery events.
Where should parsing stop and delivery diagnosis begin?
For a short-lived password reset, the send operation needs an explicit contract. The request carries an opaque message identifier and a link whose server-side expiry is 900 seconds. The response should tell the caller whether the request was accepted, rejected, or not confirmed. Acceptance is not inbox delivery. SPF, DKIM, DMARC, reputation, bounces, and recipient-side filtering still live downstream. RFC 7208 describes SPF as an authorization mechanism for hosts sending mail for a domain; it does not turn an accepted API response into proof that a message reached a mailbox.
I keep these states separate in metrics and in the retry policy. accepted can be recorded as an enqueue result. delivered requires a later delivery event, if the mail system provides one. unknown means the client cannot establish the result. Unknown is uncomfortable, but it is safer than inventing failure.
Here is the small boundary I want around the parser. It uses a generic endpoint and does not assume that a body is safe to print.
package mailclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type SendResult struct {
State string `json:"state"`
ID string `json:"id"`
}
type ResponseError struct {
Status int
ContentType string
Sample string
Kind string
}
func (e *ResponseError) Error() string {
return fmt.Sprintf("send response %s (status=%d content_type=%q)", e.Kind, e.Status, e.ContentType)
}
func Send(ctx context.Context, client *http.Client, endpoint string, payload []byte) (SendResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return SendResult{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return SendResult{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return SendResult{}, err
}
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return SendResult{}, &ResponseError{Status: resp.StatusCode, ContentType: contentType, Sample: safeSample(body), Kind: "non-success status"}
}
if !strings.HasPrefix(contentType, "application/json") {
return SendResult{}, &ResponseError{Status: resp.StatusCode, ContentType: contentType, Sample: safeSample(body), Kind: "unexpected media type"}
}
var result SendResult
if err := json.Unmarshal(body, &result); err != nil {
return SendResult{}, &ResponseError{Status: resp.StatusCode, ContentType: contentType, Sample: safeSample(body), Kind: "malformed JSON"}
}
if result.State == "" || result.ID == "" {
return SendResult{}, &ResponseError{Status: resp.StatusCode, ContentType: contentType, Sample: safeSample(body), Kind: "unknown response shape"}
}
return result, nil
}
func safeSample(body []byte) string {
const limit = 256
if len(body) > limit {
body = body[:limit]
}
return strings.TrimSpace(string(body))
}
The code deliberately returns a distinct error for a non-success status before attempting to decode an error schema. Many systems make the opposite assumption and lose the status while reporting only a parser exception. The bounded sample is diagnostic evidence, not a substitute for a redaction policy. In a real client, sanitize it before it reaches logs.
How do you retry an outcome you cannot prove?
A parse failure after the server accepted the request is the dangerous case. The client saw an unusable response, but the server may already have queued the message. Retrying with a new message ID can produce duplicate reset messages. Retrying with the same idempotency key can be safe only if the receiving contract defines that key, its retention period, and what result it returns after a timeout. A locally invented header does not create idempotency.
For this workflow I use a durable send record keyed by the account-recovery request, not by the HTTP attempt. It contains the opaque reset-request ID, expiry timestamp, message ID when known, and an outcome of accepted, rejected, or unknown. A worker can reconcile unknown through an authoritative status mechanism when one exists. If it does not, the product should ask the user to request a new link after the old one is invalidated, while the service keeps the ambiguous attempt visible for investigation.
The time budget is concrete: a 900-second token should not spend several minutes in blind retries. Use a short bounded retry window, exponential backoff with jitter, and a deadline that leaves room for a fresh request. A retry policy is part of the security design here. More attempts do not automatically mean better delivery.
Instrument the signal before raising the threshold
The initial page is useful only if it leads to a change. Record counters by response class, status family, content type family, and endpoint version. Record latency histograms separately for connection time, response time, and reconciliation time. Keep the raw body out of metric labels.
The instrumentation change I would make first is a counter for accepted, rejected, and unknown, plus a counter for malformed_json tagged with a coarse source such as direct, gateway, or unknown. Then attach the correlation ID to a trace span and retain a short, redacted sample in the incident record. This lets an on-call engineer compare a parse spike with gateway errors, deploys, and downstream delivery events instead of staring at one exception string.
A threshold set too low pages on one truncated response during a deploy. A threshold set too high hides a contract regression until password resets pile up. I prefer a multi-signal page: a sustained increase in ambiguous outcomes, a drop in accepted responses, and a corresponding rise in user-visible reset failures. The exact threshold belongs to normal traffic volume and the error budget; the principle is stable. Alert on impact and evidence together.
A runbook that survives the next deploy
When the page fires, freeze the evidence first. Check whether the body is empty, HTML, truncated, or valid JSON with an unexpected field set. Compare the response content type and status with the client contract. Check recent changes at the client, gateway, and mail-queue boundaries. Search by correlation ID, then by the durable reset-request ID.
Do not rotate credentials, change DNS policy, or widen retries because a parser failed. Those actions address different failure classes. If delivery events show accepted messages while parsing errors rise, the immediate issue is the response boundary. If accepted messages fall and SPF or authentication checks are failing, the investigation has moved into mail configuration and reputation. Keep those tracks separate in the incident timeline.
The useful postmortem question is not “Why did JSON parsing break?” It is “Which result did the caller fail to establish, and what evidence would have established it without sending another message?” That question leads to better contracts, safer retries, and pages that arrive before users report missing access.
A password-reset sender is a small system with a large blast radius. Treat the response as evidence, preserve uncertainty explicitly, and make delivery a later state transition. That is how a malformed response becomes an actionable reliability signal instead of a duplicate-message generator.
Top comments (0)