Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions includes/API/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ final class Client {
*/
private static $allow_retry = true;

/**
* Retry-After header from the most recent response, in seconds, if any.
*
* @var int|null
*/
private static $last_retry_after = null;

/**
* HTTP timeout in seconds for beehiiv API requests.
*
Expand All @@ -51,13 +58,14 @@ final class Client {
/**
* Make an authenticated GET request to beehiiv v2.
*
* @param string $path Relative API path (e.g. `/publications`).
* @param array<string,mixed> $query Query parameters.
* @param string $path Relative API path (e.g. `/publications`).
* @param array<string,mixed> $query Query parameters.
* @param int $timeout HTTP timeout in seconds; 0 uses the default.
* @return array<string, mixed>
* @since 1.0.0
*/
public static function request( string $path, array $query = [] ): array {
return self::send( 'GET', $path, $query, null );
public static function request( string $path, array $query = [], int $timeout = 0 ): array {
return self::send( 'GET', $path, $query, null, $timeout );
}

/**
Expand All @@ -80,6 +88,16 @@ public static function get_last_wp_error(): ?string {
return self::$last_wp_error;
}

/**
* Retry-After header from the most recent response, in seconds.
*
* @return int|null Null when the response had no numeric Retry-After header.
* @since 1.0.0
*/
public static function get_last_retry_after(): ?int {
return self::$last_retry_after;
}

/**
* Make an authenticated POST request with a JSON body.
*
Expand Down Expand Up @@ -125,10 +143,11 @@ public static function patch( string $path, array $body, array $query = [] ): ar
* @param string $path Relative API path.
* @param array<string,mixed> $query Query parameters.
* @param array<string,mixed>|null $body JSON body for POST and PATCH; null for GET and DELETE.
* @param int $timeout HTTP timeout in seconds; 0 uses the default.
* @return array<string, mixed>
* @since 1.0.0
*/
private static function send( string $method, string $path, array $query, ?array $body ): array {
private static function send( string $method, string $path, array $query, ?array $body, int $timeout = 0 ): array {

$access_token = Manager::get_access_token();
$url = OAuthConfig::get_api_base_url() . $path;
Expand All @@ -139,7 +158,7 @@ private static function send( string $method, string $path, array $query, ?array

$args = [
'method' => $method,
'timeout' => self::REQUEST_TIMEOUT,
'timeout' => $timeout > 0 ? $timeout : self::REQUEST_TIMEOUT,
'sslverify' => OAuthConfig::should_verify_ssl(),
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
Expand All @@ -157,6 +176,10 @@ private static function send( string $method, string $path, array $query, ?array
self::$last_status_code = is_wp_error( $res ) ? null : (int) wp_remote_retrieve_response_code( $res );
self::$last_wp_error = is_wp_error( $res ) ? $res->get_error_message() : null;

self::$last_retry_after = is_wp_error( $res )
? null
: self::parse_retry_after( wp_remote_retrieve_header( $res, 'retry-after' ) );

$raw = is_wp_error( $res ) ? '' : (string) wp_remote_retrieve_body( $res );
$json = json_decode( $raw, true );

Expand All @@ -165,12 +188,35 @@ private static function send( string $method, string $path, array $query, ?array
$refreshed = TokenRefresher::refresh();

if ( ! is_wp_error( $refreshed ) ) {
return self::send( $method, $path, $query, $body );
return self::send( $method, $path, $query, $body, $timeout );
}
}

self::$allow_retry = true;

return is_array( $json ) ? $json : [];
}

/**
* Seconds to wait from a Retry-After header, given as seconds or as an HTTP date.
*
* @param string|array<int,string> $value Header value.
* @return int|null Seconds (0 or more), or null when absent or unparsable.
* @since 1.0.0
*/
private static function parse_retry_after( $value ): ?int {
$value = is_array( $value ) ? (string) reset( $value ) : trim( (string) $value );

if ( '' === $value ) {
return null;
}

if ( is_numeric( $value ) ) {
return max( 0, (int) $value );
}

$timestamp = strtotime( $value );

return false === $timestamp ? null : max( 0, $timestamp - time() );
}
}
170 changes: 158 additions & 12 deletions includes/API/Resources/Posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,13 @@ public static function get( string $publication_id, string $post_id ): array {
/**
* Delete or archive a post in a beehiiv publication.
*
* Confirmed posts are archived; drafts are permanently deleted.
* Confirmed posts are archived; drafts are permanently deleted. beehiiv answers
* 202 while a Send API post is still processing, in which case nothing was
* deleted yet; `status_code` lets callers tell that apart from a 204.
*
* @param string $publication_id Publication ID.
* @param string $post_id beehiiv post ID.
* @return array{success: bool, error: string}
* @return array{success: bool, error: string, status_code: int|null, retry_after: int|null}
* @since 1.0.0
*/
public static function delete( string $publication_id, string $post_id ): array {
Expand All @@ -151,8 +153,10 @@ public static function delete( string $publication_id, string $post_id ): array

if ( '' === $publication_id || '' === $post_id ) {
return [
'success' => false,
'error' => 'Publication ID or post ID is empty.',
'success' => false,
'error' => 'Publication ID or post ID is empty.',
'status_code' => null,
'retry_after' => null,
];
}

Expand All @@ -167,33 +171,175 @@ public static function delete( string $publication_id, string $post_id ): array

if ( null !== $wp_error ) {
return [
'success' => false,
'error' => $wp_error,
'success' => false,
'error' => $wp_error,
'status_code' => null,
'retry_after' => null,
];
}

$status_code = Client::get_last_status_code();
$retry_after = Client::get_last_retry_after();

if ( null !== $status_code && 204 === $status_code ) {
return [
'success' => true,
'error' => '',
'success' => true,
'error' => '',
'status_code' => $status_code,
'retry_after' => $retry_after,
];
}

if ( null !== $status_code && $status_code > 299 ) {
return [
'success' => false,
'error' => self::format_error_message( $response, $status_code ),
'success' => false,
'error' => self::format_error_message( $response, $status_code ),
'status_code' => $status_code,
'retry_after' => $retry_after,
];
}

return [
'success' => true,
'error' => '',
'success' => true,
'error' => '',
'status_code' => $status_code,
'retry_after' => $retry_after,
];
}

/**
* Whether a post created through the Send API has finished being created.
*
* The beehiiv API creates posts in the background: the create call returns
* the id right away, and fetching the post answers 202 until creation
* finishes, then 200. A 404 means creation failed. Rate limiting, server
* errors and transport errors also count as pending, so callers keep waiting
* rather than testing a post that may not exist yet.
*
* @link https://developers.beehiiv.com/api-reference/posts/get
*
* @param string $publication_id Publication ID.
* @param string $post_id beehiiv post ID.
* @param int $timeout HTTP timeout in seconds; 0 uses the client default.
* @return array{state: string, retry_after: int|null, status_code: int|null, error: string}
* The state is one of `ready`, `pending`, `failed`, or `unknown`.
* @since 1.0.0
*/
public static function get_creation_state( string $publication_id, string $post_id, int $timeout = 0 ): array {
$publication_id = trim( $publication_id );
$post_id = trim( $post_id );
$result = [
'state' => 'unknown',
'retry_after' => null,
'status_code' => null,
'error' => '',
];

if ( '' === $publication_id || '' === $post_id ) {
$result['error'] = 'Publication ID or post ID is empty.';
return $result;
}

$path = sprintf(
'/publications/%s/posts/%s',
rawurlencode( $publication_id ),
rawurlencode( $post_id )
);
$response = Client::request( $path, [], $timeout );

$wp_error = Client::get_last_wp_error();

if ( null !== $wp_error ) {
$result['state'] = 'pending';
$result['error'] = $wp_error;
return $result;
}

$status_code = Client::get_last_status_code();
$result['status_code'] = $status_code;
$result['retry_after'] = Client::get_last_retry_after();

if ( 200 === $status_code ) {
$result['state'] = 'ready';
} elseif ( 202 === $status_code || 429 === $status_code || ( null !== $status_code && $status_code >= 500 ) ) {
$result['state'] = 'pending';
$result['error'] = 202 === $status_code ? '' : self::format_error_message( $response, $status_code );
} elseif ( 404 === $status_code ) {
$result['state'] = 'failed';
$result['error'] = self::format_error_message( $response, $status_code );
} elseif ( null !== $status_code ) {
$result['error'] = self::format_error_message( $response, $status_code );
}

return $result;
}

/**
* Send a test email of a beehiiv post to one or more addresses.
*
* @link https://developers.beehiiv.com/api-reference/posts/test-send
*
* @param string $publication_id Publication ID.
* @param string $post_id beehiiv post ID.
* @param array<int,string> $recipient_emails Addresses to send the test to.
* @return array{
* success: bool,
* status_code: int|null,
* remaining_test_sends: int|null,
* reset_at: int|null,
* error: string
* }
* @since 1.0.0
*/
public static function test_send( string $publication_id, string $post_id, array $recipient_emails ): array {
$publication_id = trim( $publication_id );
$post_id = trim( $post_id );
$result = [
'success' => false,
'status_code' => null,
'remaining_test_sends' => null,
'reset_at' => null,
'error' => '',
];

if ( '' === $publication_id || '' === $post_id ) {
$result['error'] = 'Publication ID or post ID is empty.';
return $result;
}

$path = sprintf(
'/publications/%s/posts/%s/test_sends',
rawurlencode( $publication_id ),
rawurlencode( $post_id )
);
$response = Client::post( $path, [ 'recipient_emails' => array_values( $recipient_emails ) ] );

$wp_error = Client::get_last_wp_error();

if ( null !== $wp_error ) {
$result['error'] = $wp_error;
return $result;
}

$status_code = Client::get_last_status_code();
$result['status_code'] = $status_code;

if ( null !== $status_code && $status_code > 299 ) {
$result['error'] = self::format_error_message( $response, $status_code );
return $result;
}

$data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [];
$remaining = $data['remaining_test_sends'] ?? null;
$reset_at = $data['reset_at'] ?? null;

$result['success'] = true;
$result['remaining_test_sends'] = is_numeric( $remaining ) ? (int) $remaining : null;
$result['reset_at'] = is_numeric( $reset_at ) ? (int) $reset_at : null;

return $result;
}

/**
* Update an existing post in a beehiiv publication.
*
Expand Down
4 changes: 2 additions & 2 deletions includes/Newsletter/Sender.php
Original file line number Diff line number Diff line change
Expand Up @@ -1009,7 +1009,7 @@ private static function clear_error( int $post_id ): void {
* @return string
* @since 1.0.0
*/
private static function format_save_error_message( \WP_Error $error ): string {
public static function format_save_error_message( \WP_Error $error ): string {
switch ( $error->get_error_code() ) {
case 'beehiiv_post_template_id_empty':
return __(
Expand Down Expand Up @@ -1131,7 +1131,7 @@ private static function publication_fallback_message(
* @return string Mapped message, or empty when input is empty.
* @since 1.0.0
*/
private static function format_api_error_message( string $error ): string {
public static function format_api_error_message( string $error ): string {
$error = trim( $error );

if ( '' === $error ) {
Expand Down
Loading
Loading