Skip to content

feat(java,php): expose the http status code on the raw response - #17580

Open
wiebren wants to merge 2 commits into
fern-api:mainfrom
wiebren:fix/raw-response-status-code
Open

feat(java,php): expose the http status code on the raw response#17580
wiebren wants to merge 2 commits into
fern-api:mainfrom
wiebren:fix/raw-response-status-code

Conversation

@wiebren

@wiebren wiebren commented Aug 29, 2026

Copy link
Copy Markdown

Description

Linear ticket: n/a — found while running one OpenAPI document through all six SDK generators
and comparing what each one hands its caller.

withRawResponse() gives a caller the body and the headers but not the status code, so 200,
201 and 202 are indistinguishable.

That matters for any api that answers 202 Accepted when a call was taken but is still
running, and 200 OK when it is done. Plenty do: submitting an order comes back as 200 when
the store could fulfil it while you waited, and 202 when it was only queued. The body is
byte-identical in both cases — OrderResponse has no async flag — so the http status is the
entire signal. A client that cannot report it silently tells its caller the order succeeded
when it has not happened yet.

The same blindness covers 201: "created" and "here it is" are both just a body.

var response = client.orders().withRawResponse().submitOrder(id, request);
response.body();       // the order
response.headers();    // X-Job-Id
response.statusCode(); // ← did not exist: 200 or 202?

Today a caller works around it by reaching underneath the sdk — an okhttp Interceptor in
java, a psr-18 decorator in php — to record what the transport saw. Both are per-client global
state, so neither is safe when calls overlap.

Changes Made

  • javaHttpResponse<T> gains statusCode() next to body() and headers(), read from
    rawResponse.code() in the constructor it is already handed. The class is a static resource
    emitted by the v1 generator and referred to by name from java-v2, so the tandem picks the
    change up in one place; java-v2's features.yml description is updated to name the new
    accessor.
  • phpHttpResponse gains getStatusCode() next to getBody() and getHeaders(),
    taken from $response->getStatusCode() in HttpResponse::from. features.yml and the
    README snippet name it.
  • Updated README.md generator (if applicable) — feature descriptions and the php snippet.

Both changes are additive: an existing caller of body()/getBody() or
headers()/getHeaders() sees no difference.

Stacking note. This is stacked on #17579 ("access response headers through
withRawResponse()"), which is what gives php a raw response at all, so its commit shows up
in the diff here too — review only the second one. The java half is independent of it: if you
would rather have the java accessor on its own, drop the first commit and this one still
applies.

Testing

  • Unit tests added/updated
  • Manual testing completed

java. ./gradlew :sdk:test :generator-utils:test passes. The generated class was then
compiled for real: a production OpenAPI document of ≈900 endpoints, generated with this
branch's generator image, builds under maven with JDK 21 — which compiles both the emitted
<Prefix>HttpResponse and every withRawResponse() call site in it.

php. The same fourteen php-sdk seed fixtures — 19/19 configurations — pass with the
scripts: composer install && composer build && composer analyze && composer test, i.e.
phpstan at level max and phpunit. exhaustive (2), pagination (3), folders (2),
unions (2), streaming, server-sent-events, multi-url-environment,
endpoint-security-auth, file-download, bytes-download, enum, unknown,
nullable-optional, response-property.

Seed snapshots are not committed here, following the auto-update-seed workflow that owns them.

End to end, against the three cases this is about. A contract suite puts the same
request/response pair to every generated client and records what each one could report. The
three status cases are the same call answered 200, 202 and 201 with an identical body;
two header cases sit next to them. ok* in that table means the client only managed it because
the runner added something the sdk does not provide.

case java before java after php before php after
sync (200) ok* interceptor ok ok* psr-18 decorator ok
async (202) ok* interceptor ok ok* psr-18 decorator ok
created (201) ok* interceptor ok ok* psr-18 decorator ok
job id on a success ok ok ok* ok
job id on an error ok ok ok* ok

Both runners were rewritten to drop their workarounds entirely — the java one no longer builds
an OkHttpClient with an interceptor, the php one no longer wraps the psr-18 client — and both
now read statusCode()/getStatusCode() off the response the sdk returns. Every row is a real
execution against a local mock api that serves the canned response of the case and records what
went over the wire.

Notes

  • The status is stored at construction rather than kept as a reference to the transport
    response, so the wrapper stays independent of the http client and nothing hands out a
    half-consumed body stream.
  • No new class, no new method on any client: this is one field and one accessor per language.

Generated with Claude Code


Devin Review

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI Review Summary

Adds statusCode()/getStatusCode() to the raw response wrappers in Java and PHP, plus (from the stacked PR) the whole PHP raw-client machinery. The Java half is small and clean. In PHP a few things stand out: the raw client constructor's parameter naming is inconsistent with what the body assigns, withRawResponse() passes client options where the constructor expects environment ordering assumptions, and the README snippet is emitted unconditionally even for clients where no raw client exists.

  • 🔴 1 critical issue(s)
  • 🟡 3 warning(s)
  • 🔵 2 suggestion(s)

To request another review, comment /ai-review on this pull request.

Comment on lines +129 to +134
const parameters: php.Parameter[] = [
php.parameter({
name: "$client",
type: php.Type.reference(context.rawClient.getClassReference())
})
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 critical

The first parameter is named $client (note the leading $ in the name, unlike the other parameters which pass bare names), but the body assigns $this->client = $${context.rawClient.getFieldName()}. If getFieldName() isn't literally client, the generated constructor references an undefined variable. Also the inconsistent $ prefix between this parameter and the ones below will produce $$environment-style names if the codegen prefixes automatically. Please make naming uniform and derive the parameter name from context.rawClient.getFieldName().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed (same comment on #17579). It did render correctly — php.parameter runs the name through convertToPhpVariableName, so "$client" and "client" both emit $client, and there is no $$environment case — but the spelling was inconsistent, so the parameter name and the assignment now both come from context.rawClient.getFieldName().

Comment on lines +25 to +28
const arguments_: php.AstNode[] = [php.codeblock(`$this->${context.rawClient.getFieldName()}`)];
arguments_.push(
isMultiUrl ? php.codeblock("$this->environment") : php.codeblock(`$this->${context.getClientOptionsName()}`)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

In the multi-URL case the constructor's second parameter is environment (required, non-optional) while in the single-URL case it's options. This helper mirrors that by position only — any future reorder in getRawClientConstructor silently breaks it. Worth extracting a single shared descriptor of the constructor's parameter list so the two can't drift.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point, and I have removed the positional coupling entirely rather than just documenting it. Both sides are now generated from one ordered descriptor in raw-client/rawClientConstructor.ts:

interface RawClientConstructorParameter {
    parameter: php.Parameter;
    argument: php.AstNode;   // what the plain client passes
    assignment: string;      // how the raw client keeps it
}

getRawClientConstructor() maps .parameter and writes .assignment; getRawClientConstructorArguments() — used by withRawResponse() — maps .argument. A reorder moves both at once, and adding a parameter cannot be half-done.

}
if (isMultiUrl) {
writer.writeTextStatement("$this->environment = $environment");
writer.writeTextStatement(`$this->${context.getClientOptionsName()} = []`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

For multi-URL clients the raw client's clientOptions is hard-coded to [], discarding the parent client's options (headers, timeouts, base overrides). Endpoint bodies read $this->{clientOptions}, so raw calls on multi-URL SDKs will behave differently from plain calls. Pass the options through here as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Real bug, fixed. The plain root client keeps its options in the multi-url case, so resetting them to [] meant a raw root call lost the base-url override, headers, timeout and retries. Options are now always taken and always forwarded:

public function __construct(
    RawClient $client,
    Environments $environment,
    ?array $options = null,
) { ... }

// and, on the plain client:
return $this->rawResponseClient ??= new RawS3Client($this->client, $this->environment, $this->options);

snippets[FernGeneratorCli.StructuredFeatureId.Timeouts] = this.buildTimeoutSnippets();
snippets[FernGeneratorCli.StructuredFeatureId.CustomClient] = this.buildCustomClientSnippets();
snippets[ReadmeSnippetBuilder.EXCEPTION_HANDLING_FEATURE_ID] = this.buildExceptionHandlingSnippets();
snippets[ReadmeSnippetBuilder.ACCESS_RAW_RESPONSE_DATA_FEATURE_ID] = this.buildRawResponseSnippets();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

ACCESS_RAW_RESPONSE_DATA snippets are added unconditionally, but the raw client is only generated when a service has endpoints. For an IR whose selected feature endpoint lives on an endpoint-less package this emits a README snippet calling a method that doesn't exist. Guard it the same way the generators do (service.endpoints.length > 0).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I do not think this case can arise. getEndpointsForFeature resolves to real endpoints — the ones configured in readmeConfig.features, or [this.defaultEndpointId] — and an endpoint always belongs to a service, so the client the snippet addresses has at least one endpoint and therefore has a raw client. There is no path where an endpoint id maps to an endpoint-less package.

This also matches how the neighbouring snippet builders work: buildExceptionHandlingSnippets, buildRetrySnippets and buildTimeoutSnippets are all emitted unconditionally off the same helper. Happy to add a guard anyway if you can point at an IR shape where it breaks.

Comment on lines +101 to +109
public function getHeader(string $name): array
{
foreach ($this->headers as $header => $values) {
if (strcasecmp($header, $name) === 0) {
return $values;
}
}
return [];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

getHeader is O(n) per lookup over all headers. Fine for typical header counts, but if you want case-insensitive lookup cheaply, build a lowercased index once in the constructor. Not blocking.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair, and deliberate — a lowercased index would mean building and holding a second array on every response for a lookup over what is typically a handful of headers, and getHeaders() has to keep the original casing anyway. Left as the linear scan; happy to revisit if a profile ever says otherwise.

Comment on lines 76 to +77
writer.writeTextStatement("$this->body = $body");
writer.writeTextStatement("$this->headers = $headers");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

$this->headers = $headers is assigned before parent::__construct(...). Harmless here, but conventionally the parent constructor runs first; keeping the order consistent avoids surprises if the base class ever touches subclass state.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This matches the constructor that is already generated — $this->body = $body; is likewise assigned before parent::__construct(...) — so putting the headers first keeps the two adjacent instead of splitting them around the parent call. Reordering both would change output that is not part of this change. The base class is Exception via the generated base exception, which does not read subclass state.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +311 to +317
class_.addMethod(
getWithRawResponseMethod({
context: this.context,
rawClassReference: this.context.getRawRootClientClassReference(),
isMultiUrl
})
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Raw responses disappear behind interfaces

When generateClientInterfaces is enabled, the generated interfaces omit withRawResponse(). Consumers typed against those interfaces cannot access any response metadata.

Prompt for agents
Add withRawResponse() signatures to both generated client-interface variants when their corresponding service has endpoints. Update RootClientInterfaceGenerator and SubPackageClientInterfaceGenerator so the interface contract matches the new public methods emitted by RootClientGenerator and SubPackageClientGenerator, using the appropriate raw root or raw subpackage return type. Cover generateClientInterfaces configurations for root and nested service clients.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Implemented. RootClientInterfaceGenerator and SubPackageClientInterfaceGenerator now declare withRawResponse() whenever the service has endpoints, returning the concrete raw client — the shape java and go use. The folders:with-interfaces fixture passes with the scripts (phpstan at level max and phpunit); the five interfaces with endpoints gained the method.

@wiebren
wiebren force-pushed the fix/raw-response-status-code branch 2 times, most recently from 954b1e2 to a69529c Compare August 29, 2026 11:43
A php endpoint method read the response and returned only the deserialized body, so a
response header was unreachable: a caller who needed one had to decorate the psr-18 client.
Every other fern sdk exposes the raw response - c#, go, java, python and typescript - and php
was the only one that did not.

Every generated client that has endpoints gains `withRawResponse()`, returning a raw
counterpart whose endpoints return `HttpResponse<T>`: `getBody()` is exactly what the plain
client returns, next to `getHeaders()`, `getHeader()` and `getHeaderLine()`. The api exception
carries the response headers too, so a header like `X-Process-Id` is reachable on the error
path as well as the success path.
`withRawResponse()` handed a caller the body and the headers but not the status, so 200, 201
and 202 were indistinguishable. For an api that answers 202 when a command was accepted but is
still running and 200 when it is done - with the same body either way - the status is the whole
signal, and a caller had to add an okhttp interceptor (java) or decorate the psr-18 client
(php) to see it.

java's `HttpResponse` gains `statusCode()` next to `body()` and `headers()`; php's
`HttpResponse` gains `getStatusCode()` next to `getBody()` and `getHeaders()`. Both read it
from the response they are already handed, so nothing else changes.
@wiebren
wiebren force-pushed the fix/raw-response-status-code branch from a69529c to 7d3e7a6 Compare August 29, 2026 11:48
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.

1 participant